<?php
/**
 * Date: 2024.8
 * Author: kahn
 * 符合psr16的文件缓存系统
 */

namespace psr16Cache;

use DateInterval;
use Psr\SimpleCache\CacheInterface;
use Psr\SimpleCache\InvalidArgumentException;

class FileCache implements CacheInterface
{
    /**
     * @var ?string 缓存文件存放目录
     */
    public ?string $cachePath = null;

    /**
     * @var string 缓存文件后缀，默认为 ".cache"
     */
    public string $cacheFileSuffix = '.cache';

    /**
     * @var int 子目录级别
     * 如果没有子目录，当文件数量巨大(如1万以上)，你可能需要将此值设置为1或2，以降低文件系统的压力
     * 此属性值不应超过16，推荐使用一个小于3的值
     */
    public int $directoryLevel = 0;

    /**
     * @var string 键(key)的前缀
     */
    public string $keyPrefix = '';

    /**
     * @var int 垃圾回收概率 默认0.01％的概率
     */
    private int $_gcProbability = 100;

    /**
     * @var bool 是否已触发了垃圾回收
     */
    private bool $_gced = false;

    public function __construct($config = array())
    {
        foreach ($config as $name => $value) {
            $this->$name = $value;
        }

        if (!$this->cachePath) {
            $this->cachePath = sys_get_temp_dir();
        }
        static::createDirectory($this->cachePath);
    }

    /**
     * @param string $key
     * @param mixed $value
     * @param int|DateInterval|null $ttl
     * @return bool
     */
    public function set(string $key, mixed $value, \DateInterval|int|null $ttl = null): bool
    {
        if ($ttl instanceof \DateInterval) {
            $ttl = $ttl->y * 365 * 24 * 60 * 60
                + $ttl->m * 30 * 24 * 60 * 60
                + $ttl->d * 24 * 60 * 60
                + $ttl->h * 60 * 60
                + $ttl->i * 60
                + $ttl->s;
        } else {
            $ttl = (int)$ttl;
        }
        if (is_array($value)) {
            $value = json_encode($value);
        }

        return $this->setValue($this->getKey($key), $value, $ttl);
    }

    /**
     * @param string $key
     * @param mixed|null $default
     * @return mixed
     */
    public function get(string $key, mixed $default = null): mixed
    {
        $value = $this->getValue($this->getKey($key));

        if ($value === null) {
            return null;
        }

        return json_decode($value, true);


    }

    /**
     * 还剩多少秒过期 以及 缓存内容
     *
     * @param string $key
     * @return array
     * @throws InvalidArgumentException
     */
    private function getPayload(string $key): array
    {
        $emptyPayload = array('data' => null, 'time' => null);

        $cacheFile = $this->getCacheFile($this->getKey($key));

        $mtime = @filemtime($cacheFile);
        if (!$mtime) {
            return $emptyPayload;
        }

        $expire = $mtime - time();

        if ($expire <= 0) {
            $this->delete($key);
            return $emptyPayload;
        }

        $value = @file_get_contents($cacheFile);
        $value = json_decode($value, true);

        return array('data' => $value, 'time' => $expire);
    }

    /**
     * @param string $key
     * @return bool
     */
    public function delete(string $key): bool
    {
        return $this->deleteValue($this->getKey($key));
    }

    private function getKey($key): string
    {
        $illegalCharacters = array("~", "!", "@", "#", "$", "%", "^", "&", "*", "(", ")", "+", "=", "{", "}", "[", "]", "|", "\\", ":", ";", "\"", "'", "<", ">", "?", "/", ".", ",", " ");
        $key = str_replace($illegalCharacters, "_", $key);
        return $this->keyPrefix . $key;
    }

    /**
     * 删除所有缓存
     */
    private function flushValues(): bool
    {
        $this->gc(false);
        return true;
    }

    /**
     * @param string $key 添加前缀的key
     * @return string|null
     */
    private function getValue(string $key): ?string
    {
        $cacheFile = $this->getCacheFile($key);
        if (($time = @filemtime($cacheFile)) > time()) {
            return @file_get_contents($cacheFile);
        } elseif ($time > 0) {
            @unlink($cacheFile);
        }
        return null;
    }

    /**
     * @param array $keys 添加前缀的key
     * @return array
     */
    private function getValues(iterable $keys): array
    {
        $results = array();
        foreach ($keys as $key) {
            $results[$key] = $this->getValue($this->getKey($key));
        }
        return $results;
    }

    /**
     * @param string $key 添加前缀的key
     * @param string $value
     * @param integer $expire 缓存过期时间(多少秒后过期)。0表示永不过期.
     * @return boolean
     */
    private function setValue(string $key, string $value, int $expire): bool
    {
        if (!$this->_gced && mt_rand(0, 1000000) < $this->_gcProbability) {
            $this->gc();
            $this->_gced = true;
        }

        if ($expire <= 0) {
            $expire = 31536000; // 1 year
        }

        $cacheFile = $this->getCacheFile($key);
        if ($this->directoryLevel > 0)
            @static::createDirectory(dirname($cacheFile));
        if (@file_put_contents($cacheFile, $value, LOCK_EX) !== false) {
            return @touch($cacheFile, $expire + time());
        } else {
            return false;
        }
    }

    /**
     * @param string $key
     * @return bool
     */
    private function deleteValue(string $key): bool
    {
        $cacheFile = $this->getCacheFile($key);
        return @unlink($cacheFile);
    }

    /**
     * @param string $key
     * @return string
     */
    private function getCacheFile(string $key): string
    {
        if ($this->directoryLevel > 0) {
            $base = $this->cachePath;
            for ($i = 0; $i < $this->directoryLevel; ++$i) {
                if (($prefix = substr($key, $i + $i, 2)) !== false)
                    $base .= DIRECTORY_SEPARATOR . $prefix;
            }
            return $base . DIRECTORY_SEPARATOR . $key . $this->cacheFileSuffix;
        } else {
            return $this->cachePath . DIRECTORY_SEPARATOR . $key . $this->cacheFileSuffix;
        }
    }

    /**
     * 删除过期的缓存文件.
     * @param boolean $expiredOnly 如果为true 只删除过期的缓存文件  false 删除所有缓存文件
     * @param string|null $path 指定缓存目录，如果为null，则为$cachePath属性指定的目录
     */
    private function gc(bool $expiredOnly = true, string $path = null): void
    {
        if ($path === null) {
            $path = $this->cachePath;
        }
        if (($handle = opendir($path)) === false) {
            return;
        }
        while (($file = readdir($handle)) !== false) {
            if ($file[0] === '.') {
                continue;
            }
            $fullPath = $path . DIRECTORY_SEPARATOR . $file;
            if (is_dir($fullPath)) {
                $this->gc($expiredOnly, $fullPath);
            } elseif ($expiredOnly && @filemtime($fullPath) < time() || !$expiredOnly) {
                @unlink($fullPath);
            }
        }
        closedir($handle);
    }

    /**
     * 递归创建目录
     * @param string $path
     * @param int $mode
     * @return void
     */
    private static function createDirectory(string $path, int $mode = 0775): void
    {
        if (is_dir($path)) {
            return;
        }
        $parentDir = dirname($path);
        if (!is_dir($parentDir)) {
            static::createDirectory($parentDir, $mode);
        }
        mkdir($path, $mode);
        chmod($path, $mode);
    }

    /**
     * @return bool
     */
    public function clear(): bool
    {
        return $this->flushValues();
    }


    /**
     * @param iterable $keys
     * @param mixed|null $default
     * @return iterable
     */
    public function getMultiple(iterable $keys, mixed $default = null): iterable
    {
        $values = $this->getValues($keys);
        foreach ($values as $k => &$v) {
            $v = json_decode($v, true);
        }
        return $values;
    }

    /**
     * @param iterable $values
     * @param int|DateInterval|null $ttl
     * @return bool
     * @throws InvalidArgumentException
     */
    public function setMultiple(iterable $values, \DateInterval|int|null $ttl = null): bool
    {
        foreach ($values as $key => $value) {
            $this->set($key, $value, $ttl);
        }
        return true;
    }

    /**
     * @param iterable $keys
     * @return bool
     */
    public function deleteMultiple(iterable $keys): bool
    {
        foreach ($keys as $key) {
            $this->delete($key);
        }
        return true;
    }

    /**
     * @param string $key
     * @return bool
     * @throws InvalidArgumentException
     */
    public function has(string $key): bool
    {
        $payload = $this->getPayload($key);
        return $payload['time'] !== null;
    }
}